feat: a real call stack, shared by the editor and the CLI - #15
Merged
Conversation
The Callstack view showed one frame, named after a wasm instruction and carrying the replay position in its label. It now shows every frame that led to the current line, and `soroban-trace` reports the same stack per stop as `frames` — both from one derivation, `debugAdapter/callStack.ts`, so a script and a debug session can never disagree about who called whom. The stack is assembled from three sources, ordered by how much each can be trusted, and every frame states which one placed it: The trace's own wasm activations are the STRUCTURE. `computeDepths` becomes `computeFrames`, which returns the linked activation stack (`fn`, `depth`, `callSite`, `caller`) the depths were always a projection of; depth is now that projection, so the frame count and step-over/ step-out are derived from the same walk and cannot drift apart. An outer frame stands at the call it is suspended in, which is also the record its own locals were last observed at. DWARF adds the Rust frames inlining erased. `ScopeIndex.inlineScopesAt` walks the DW_TAG_inlined_subroutine instances covering a pc, resolving names through abstract_origin/specification/linkage_name and call sites through call_file/call_line; LineTable keeps each unit's file table so a call-site file INDEX resolves to a path. An instance whose range this parser cannot read (a v5 .debug_rnglists list) is skipped, never guessed at: a missing frame degrades the view, an invented one misreports the program. The trace's contract-call boundaries close the stack below as labels. Names come off a precision ladder: the DWARF subprogram name qualified by its enclosing namespaces and types, else the module's `name`-section symbol demangled (new wasm/names.ts — rustc leaves some method DIEs anonymous, so this rung matters even in a DWARF build), else the function index, else the raw offset. A frame is never nameless, and a release build with no debug info still gets `control::Control::while_call+0x1a`. Frames are inspectable per frame, not just at the top: a variables reference now encodes the frame together with the scope kind, so Locals, Value Stack and Variables answer for the SELECTED frame, read from that frame's own record. Linear memory stays at the cursor — a callee may have written through a reference the caller still holds. `stackTrace` honors the client's paging window, gives each frame its own id and instruction pointer reference (so Disassembly follows the selection), and reports a non-workspace or sourceless frame as `subtle` rather than dropping it: an optimized build can put eight SDK conversion frames between the user's code and the pc, and a stack that quietly hid them would be a lie about how the program got here. The replay position moves out of the frame name into the thread label (`soroban-vm [29/40]`). A frame name says what the program is doing; where the cursor sits is a property of the recorded thread. The rules are specified as C1-C8 in the new docs/callstack.md, pinned by test/callStack.test.ts and test/dapFrames.test.ts across the fixture spread that matters: opt-0, optimized, stripped of DWARF, without a name section, and with no wasm at all.
The extension is published as `stellar-debugger`, so the identifiers a user types or reads should not say `soroban`. Every one of them is a breaking change after the first public release and free before it, which is why they all move now: the debug type is `"type": "stellar"`, the settings are `stellar.kometNode.path` and `stellar.cliPath` (the old `soroban.stellar.path` read badly under any product name), the command is `stellar.debug`, the CLIs are `stellar-trace` and `stellar-dap`, and the thread label is `stellar-vm [n/m]`. Launch-config names, snippets, error messages, docs and examples follow. Internal identifiers deliberately keep the name — `SorobanDebugSession`, `SorobanLaunchArgs`, `src/soroban/**` — because they refer to the Soroban protocol layer rather than to the product, and renaming them would churn the tree without changing anything a user sees. Prose keeps "Soroban" wherever it names the platform. The CLI docs also state that the marketplace build does not install `stellar-trace` or `stellar-dap`; they are built from this repository.
Marketplace metadata the listing needs: a 128x128 icon (generated from `images/icon.svg`, regeneration documented in `images/README.md`), a gallery banner, keywords, a Testing category alongside Debuggers, and `preview: true` for a first release. The version is 0.1.0. `.vscodeignore` becomes an allowlist — ignore everything, then add back `dist/`, the icon and the four metadata files. A denylist is a trap here: `vsce` packages from the disk rather than from git, and `examples/*/target` plus `test/fixtures/*/target` are roughly 6 GB of gitignored Rust build output. `vsce ls` now reports 8 files and the package is 905 KB. CI packages on every run and fails if the `.vsix` grows past 2 MB, so a regression cannot pass unnoticed. `release.yml` is tag-driven: it refuses a tag that disagrees with `package.json`, runs the suite with the e2e opt-out (CI has already run it against the real node on that commit), then publishes to the VS Code Marketplace and to Open VSX, which is where Cursor, Windsurf and VSCodium install from, and attaches the `.vsix` to a GitHub release. It needs the `VSCE_PAT` and `OVSX_PAT` secrets. Activation was the blanket `onDebug`, which woke the extension for any debug session and left the palette command relying on implicit activation; it is now `onDebugResolve:stellar` plus an explicit `onCommand:stellar.debug`. RELEASE-CHECKLIST.md tracks what is left before the tag and is meant to be deleted once the release is out.
The changelog was written for contributors: it explained DWARF, wasm activations, `contractspecv0`, opt-level 0 and the trace's `kind` field, none of which tells a user what they can now do. It is rewritten as 498 words instead of 1150, every entry a full sentence about observable behaviour, with the mechanism left to `docs/` and CONTRIBUTING. The `[Unreleased]` section is folded into `[0.1.0]`, and the entry for the never-released 0.0.1 is gone along with its dead tag link. The README gains an Install section (it previously said "install the extension" with no link), states that komet-node must be built with komet v0.1.87 or newer, and gains a Known limitations section covering partial traces, the opt-level-0 requirement for source stepping, and one traced transaction per session. Its Roadmap no longer contradicts its Features: the Variables view ships, inline values are what is still future. `examples/` is described as a repo clone rather than as bundled, since it does not ship in the `.vsix`. CONTRIBUTING documents the release process, the allowlist `.vscodeignore` invariant, and the decision to defer the ESLint 9 migration: ESLint 8 is end-of-life but dev-only, so it never reaches the `.vsix`. SECURITY.md and CODE_OF_CONDUCT.md are the two files a public repository is measured against and were missing. SECURITY.md states scope explicitly, including that a `launch.json` naming a malicious build command is not a vulnerability in this extension.
`.gitignore` now covers `.env`, `.env.*` and `.deps/`, all of which exist untracked in a working tree, so no secret or three-repo checkout can be committed by accident. `.vscode/launch.json` carried two personal configurations pointing at `/home/node/work/...`, and `test/justMyCode.test.ts` classified paths under `/home/node/work/rs-lending-xlm/...`, naming an internal project in a repository about to go public. The test's ground-truth paths are now neutral; the classifier keys off the `.rustup`, `.cargo/registry` and `/rustc/` markers, so it treats them identically. The stray `state.kore` in the root is deleted.
A user without one of the external dependencies previously read a symptom rather than a cause. A missing komet-node spent sixty silent seconds polling a port nothing was listening on and then blamed the health check, though the `ENOENT` was known at second zero and logged where nobody looks. A missing Stellar CLI produced `build command exited with code 127`. A komet-node older than komet v0.1.87 built, deployed and invoked the contract before failing with `trace line 1: 'kind' must be a non-empty string`, which reads as a corrupt file rather than as a version to upgrade. `src/diagnostics/setup.ts` now owns those messages. Each names what could not be done, why, the command that installs the missing piece and the setting that points at it, and closes with a link to the README's new Troubleshooting section — appended by the `SetupError` constructor, so no caller can forget it. The module is pure and free of `vscode`, so the same wording reaches the editor modal, the debug console, `stellar-trace` and `stellar-dap`. Behaviour, not only wording, changes in three places. `KometProcess` records a failed spawn or an early exit and exposes `whenFailed()`, which `LiveBackend` races against the health check: a node that cannot start now fails the launch at once, and one that dies during boot is reported with its exit code and its own last output instead of as a timeout. `ContractBuilder` keeps a bounded tail of the build output and classifies a non-zero exit from it — a missing Stellar CLI, a missing Rust toolchain, a missing wasm target, a command that is not the Stellar CLI, or an ordinary compile failure — because an exit code cannot tell those apart. A trace record without `kind` raises `StaleTraceError`, still a `TraceParseError` so existing handlers are unaffected, naming komet v0.1.87. A missing program the output does not identify is named without a guess at which dependency it belongs to: telling someone their missing `foo` is a missing Stellar CLI would be a wrong answer stated confidently. `waitForHealthy` takes a `giveUp` predicate so the poll loop stops when the node is already known to be doomed, `node.healthTimeoutMs` makes the health deadline configurable (and the failure path testable), and both CLIs print a setup error as its message alone — a stack trace in front of an explanation only buries the fix.
The Requirements section named komet-node and linked its repository, but the repository's own README does not say how to install it either — the reader was left to discover that it comes from `kup`, and that `kup` comes from Nix. The three commands are the ones `.devcontainer/Dockerfile` already runs, so the README and the container cannot drift apart. The `nix.conf` stanza the Dockerfile writes is deliberately left out: it exists only because the image build has stdin closed, where kup's prompt to register the substituters dies on EOFError. A human running this interactively just answers the prompt.
Everything it listed ships in 0.1.0, and it described how setup problems are detected rather than what a user can do.
It was a working doc for the 0.1.0 release and is kept outside the tree, under the now-ignored .notes/.
axios and form-data were the only two reaching users, bundled into dist/ through @stellar/stellar-sdk; the rest are dev-only. Every mocha up to 12.0.0-beta-2 pins vulnerable serialize-javascript, qs and diff majors, so those go through overrides rather than the downgrade of the test runner that npm audit suggests.
stellar-xdr and soroban-env-host had no patched version reachable from soroban-sdk 22. SDK 27 requires the wasm32v1-none target, which ContractBuilder already auto-detects, so neither code nor docs needed changing. Verified by tracing increment against a real komet-node: 1551 records, three source stops, variables resolving.
It was still in the pre-v0.1.87 shape, so the zero-dependency replay config that examples/README.md offers as a first step failed with the stale-node error. The test fixtures had all been re-recorded; nothing replays this copy, so the suite stayed green. It is the same 41-record run, identical once `kind` is stripped.
The README offers a recording as a bug report to hand to someone else, but the source paths come from the wasm debug info and are absolute, and nothing remaps them.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Callstack view showed one frame, named after a wasm instruction and carrying the replay position in its label. It now shows every frame that led to the current line, and
soroban-tracereports the same stack per stop asframes— both from one derivation,debugAdapter/callStack.ts, so a script and a debug session can never disagree about who called whom.The stack is assembled from three sources, ordered by how much each can be trusted, and every frame states which one placed it:
The trace's own wasm activations are the STRUCTURE.
computeDepthsbecomescomputeFrames, which returns the linked activation stack (fn,depth,callSite,caller) the depths were always a projection of; depth is now that projection, so the frame count and step-over/ step-out are derived from the same walk and cannot drift apart. An outer frame stands at the call it is suspended in, which is also the record its own locals were last observed at.DWARF adds the Rust frames inlining erased.
ScopeIndex.inlineScopesAtwalks the DW_TAG_inlined_subroutine instances covering a pc, resolving names through abstract_origin/specification/linkage_name and call sites through call_file/call_line; LineTable keeps each unit's file table so a call-site file INDEX resolves to a path. An instance whose range this parser cannot read (a v5 .debug_rnglists list) is skipped, never guessed at: a missing frame degrades the view, an invented one misreports the program. The trace's contract-call boundaries close the stack below as labels.Names come off a precision ladder: the DWARF subprogram name qualified by its enclosing namespaces and types, else the module's
name-section symbol demangled (new wasm/names.ts — rustc leaves some method DIEs anonymous, so this rung matters even in a DWARF build), else the function index, else the raw offset. A frame is never nameless, and a release build with no debug info still getscontrol::Control::while_call+0x1a.Frames are inspectable per frame, not just at the top: a variables reference now encodes the frame together with the scope kind, so Locals, Value Stack and Variables answer for the SELECTED frame, read from that frame's own record. Linear memory stays at the cursor — a callee may have written through a reference the caller still holds.
stackTracehonors the client's paging window, gives each frame its own id and instruction pointer reference (so Disassembly follows the selection), and reports a non-workspace or sourceless frame assubtlerather than dropping it: an optimized build can put eight SDK conversion frames between the user's code and the pc, and a stack that quietly hid them would be a lie about how the program got here.The replay position moves out of the frame name into the thread label (
soroban-vm [29/40]). A frame name says what the program is doing; where the cursor sits is a property of the recorded thread.The rules are specified as C1-C8 in the new docs/callstack.md, pinned by test/callStack.test.ts and test/dapFrames.test.ts across the fixture spread that matters: opt-0, optimized, stripped of DWARF, without a name section, and with no wasm at all.